webpack 热更新原理

前言

随着前端技术的不断发展,构建工具与我们的开发已密不可分,但是无论选择哪款构建工具,我们都或多或少的使用过他们热更新的能力,今天就以 webpack 为例,来看 webpack 热更新的原理。

要实现热更新的能力,首先 webpack 要具备文件监听的能力,其次也要具备模块构建的能力,所以我们就从无到有的看 webpack 热更新的原理。

一、webpack 编译原理

先创建一个项目,看在不引入构建工具的情况下会出现什么问题。

1、新建项目

1.1、新建项目

新建项目

1
2
mkdir webpack-hmr-demo
cd webpack-hmr-demo

项目结构

1
2
3
4
5
6
7
8
9
10
.
├── index.html
└── src
├── App.vue
├── assets
│ ├── css
│ │ └── index.css
│ └── js
│ └── index.js
└── main.js

main.js

1
2
3
4
5
6
7
import { createApp } from "vue";

import App from "./App.vue";
import "./assets/css/index.css";

const app = createApp(App);
app.mount("#app");

App.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<template>
<div>{{ msg }}</div>
<input type="text" />
<button @click="handleClick">点击</button>
</template>

<script>
import { defineComponent, ref } from "vue";

import { modification } from "./assets/js/index";

export default defineComponent({
name: "App",
setup() {
const msg = ref("hello world");

function handleClick() {
msg.value = modification(msg.value);
}

return {
msg,
handleClick,
};
},
});
</script>

assets/js/index.js

1
2
3
export function modification(str) {
return `${str}!!!`;
}

assets/css/index.css

1
2
3
4
#app {
font-size: 25px;
font-weight: 600;
}

之后在 index.html 中引入 main.js 文件,理想状态下,通过浏览器应该是能直接访问我们写的页面的,但是通过浏览器打开后,会发现报如下错误:

1.2、问题分析

JavaScript 有两种源文件,一种是脚本,另一种是模块。二者的区别是脚本只有语句,模块除了语句,还有 import 声明或 export 声明。

main.js 文件属于模块,而 script 标签的 type 属性默认是 text/javascript ,所以浏览器默认会将它作为脚本进行解析,所以此处需要把 type 修改为 module ,将 main.js 作为模块引入。

但是修改后依然报错。

这是因为脚本被跨域策略给拦截了,我们使用的协议是 file、而跨域请求只支持这些协议:http、data、chrome、chrome-extension、chrome-untrusted、https,这里我们需要一个服务来作为资源的提供方,最简单的方式就是通过 VSCode 安装一个插件 Live Server。

跨域解决了但是项目依然跑不起来,因为没有引入 vue 的模块,即使在 node_modules 下安装了浏览器也找不到,因为它没有对应的查找规则,并且在项目中我们也不能需要什么就通过 cdn 的方式引入或直接把对应模块下载到本地,这个在后期模块的维护管理方面都会带来问题。

所以这个时候,我们就需要使用构建工具来帮我们对文件做处理让浏览器能认识。

2、使用

2.1、安装依赖

1
2
3
4
5
6
7
yarn init -y

yarn add webpack webpack-cli --save-dev
yarn add @babel/core @babel/preset-env babel-loader --save-dev
yarn add vue vue-loader vue-template-compiler --save-dev
yarn add html-webpack-plugin --save-dev
yarn add css-loader style-loader --save-dev

2.2、项目配置

配置 build/webpack.dev.js 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const path = require("path");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const { VueLoaderPlugin } = require("vue-loader");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
mode: "development",
entry: path.join(__dirname, "../src/main.js"),
output: {
filename: "bundle.js",
path: path.join(__dirname, "../dist"),
},
module: {
rules: [
{
test: /\.js$/,
exclude: "/node_modules/",
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-env"],
},
},
},
{
test: /\.vue$/,
loader: "vue-loader",
},
{
test: /\.css$/,
use: ["style-loader", "css-loader"],
},
],
},
plugins: [
new CleanWebpackPlugin(),
new VueLoaderPlugin(),
new HtmlWebpackPlugin({
template: "./index.html",
}),
],
};

配置编译命令

1
2
3
4
5
{
"scripts": {
"build": "webpack --config build/webpack.dev.js",
},
}

2.3、运行

1
yarn build

3、编译结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(() => {
"use strict";
var __webpack_modules__ = ([])

function __webpack_require__(moduleId) {}

//...

var __webpack_exports__ = {};

(() => {
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var vue__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! vue */ "./node_modules/vue/dist/vue.runtime.esm-bundler.js");
/* harmony import */ var _App_vue__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./App.vue */ "./src/App.vue");
/* harmony import */ var _assets_css_index_css__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./assets/css/index.css */ "./src/assets/css/index.css");

var app = (0,vue__WEBPACK_IMPORTED_MODULE_0__.createApp)(_App_vue__WEBPACK_IMPORTED_MODULE_1__["default"]);
})();
})();
  • 打包后的结果是一个IIFE(匿名闭包)
  • webpack_modules 用来记录当前依赖的所有模块
  • webpack_require 用来加载模块
  • 最后一段 IIFE 对应的就是我们开头写好的 main.js 文件

4、实现原理

4.1、执行过程

4.2、原理分析

二、文件监听

经过构建工具的处理,浏览器已经能运行我们写好的代码了,但是每次修改完看渲染效果都需要手动去编译,这就很影响开发效率。

webpack 提供了监听文件变化,变化后就自动编译的策略。

1、使用

1.1、安装依赖

同上

1.2、项目配置

在配置 webpack.config.js 中设置 watch: true 或者启动 webpack 命令时带上 –watch 参数:

1
2
3
4
5
6
{
"scripts": {
"build": "webpack --config build/webpack.dev.js",
"watch": "webpack --config build/webpack.dev.js --watch",
},
}

1.3、运行

运行后每次修改完保存后 webpack 都会自动帮我们去编译。

1
yarn watch

2、编译结果

同上

3、实现原理

3.1、执行过程

与一般编译不同的是,在红色区域会自动监听文件变化。

3.2、原理分析

1
2
3
4
5
6
7
8
9
if (watch) {
compiler.watch(watchOptions, callback);
} else {
compiler.run((err, stats) => {
compiler.close(err2 => {
callback(err || err2, stats);
});
});
}

在未开启文件监听的功能时,webpack 构建走的是 compiler.run 方法,当开启文件监听的功能后,webpack 在进行构建时会走 compiler.watch 方法。

而且他们构建后的内容是完全相同的,唯一不同的是开启文件监听会监听文件变化自动构建。所以这里我们主要关注两个问题

  • webpack 怎么做到的监听
  • 监听到变化后的处理机制

📌 webpack 怎么做到的监听

Webpack5 是通过 node 的 fs 模块创建监听器来实现文件监听的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// node_modules/watchpack/lib/watchEventSource.js

class DirectWatcher {
constructor(filePath) {
this.filePath = filePath;
this.watchers = new Set();
this.watcher = undefined;
try {
const watcher = fs.watch(filePath);
this.watcher = watcher;
watcher.on("change", (type, filename) => {
for (const w of this.watchers) {
w.emit("change", type, filename);
}
});
watcher.on("error", error => {
for (const w of this.watchers) {
w.emit("error", error);
}
});
} catch (err) {
process.nextTick(() => {
for (const w of this.watchers) {
w.emit("error", err);
}
});
}
watcherCount++;
}
}

在 webpack 5 以前,mac 上文件监听上报有问题,要依赖了第三方包 chokidar,webpack 5 已重构。

📌 监听到变化后的处理机制

webpack 在监听到文件变化后并不会立刻去构建,它有一套自己的处理机制,主要通过 wathcOptions 的 poll 和 aggregateTimeout 属性来控制,poll 属性是控制多长时间去轮询看文件是否变化,aggregateTimeout 属性是经过 poll 属性的轮询检测到文件变化后需要多久才去执行构建。

1
2
3
4
5
6
7
8
module.export = {
watch: true,
wathcOptions: { // 只有开启监听模式时,watchOptions才有意义
ignored: /node_modules/,
aggregateTimeout: 300, // 监听到变化发生后会等300ms再去执行
poll: 1000 // 判断文件是否发生变化是通过不停询问系统指定文件有没有变化实现的
}
}

三、热重载 live reload

文件监听的方式虽然能自动帮我们编译代码,但是每次更新都需要手动刷新浏览器,这个体验是非常不友好的,所以 webpack 利用 webpack-dev-server 实现了不需要手动刷新浏览器,编译后自动刷新浏览器的方式。

1、使用

1.1、安装依赖

1
yarn add -D webpack-dev-server

1.2、项目配置

1
2
3
4
5
"scripts": {
"build": "webpack --config build/webpack.dev.js",
"watch": "webpack --config build/webpack.dev.js --watch",
"dev": "webpack-dev-server --config build/webpack.dev.js --open"
},

因为 webpack5 默认会开启热更新,所以这里先手动关闭它。

1
2
3
devServer: {
hot: false // 为 false 时文件发生变化会刷新浏览器
}

1.3、运行

1
yarn dev

修改完保存代码浏览器就会自动刷新重新加载代码。

2、编译结果

在原来构建的基础上多了两个模块。

1
2
3
__webpack_require__("./node_modules/webpack-dev-server/client/index.js?protocol=ws%3A&hostname=0.0.0.0&port=8080&pathname=%2Fws&logging=info&reconnect=10");
/******/
__webpack_require__("./node_modules/webpack/hot/dev-server.js");

3、实现原理

3.1、执行过程

3.2、原理分析

这里我们需要关注三个问题:

  • 热重载和文件监听相比,构建的差异是什么?
  • 浏览器和服务端怎么做的通讯?具体在传递什么信息?
  • 浏览器怎么做到的更新?

热重载和文件监听相比,构建的差异是什么?

相比较文件监听,热重载会通过 webpack-dev-server 调用 express 起了一个 node 服务。

然后创建 Compiler 实例,之后也会走 Compiler.watch 方法执行构建。

不同的是编译后的内容没有输出到磁盘,而是输出到了内存,提供一个地址去让你访问。

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
const express = require("express");

/** @typedef {import("webpack").Compiler} Compiler */

class Server {
constructor(options = {}, compiler) {
this.compiler = /** @type {Compiler | MultiCompiler} */ (compiler);
}

setupApp() {
/** @type {import("express").Application | undefined}*/
// eslint-disable-next-line new-cap
this.app = new /** @type {any} */ (express)();
}

setupDevMiddleware() {
const webpackDevMiddleware = require("webpack-dev-middleware");

// middleware for serving webpack bundle
this.middleware = webpackDevMiddleware(
this.compiler,
this.options.devMiddleware
);
}

async start() {
const listenOptions = this.options.ipc
? { path: this.options.ipc }
: { host: this.options.host, port: this.options.port };

await /** @type {Promise<void>} */ (
new Promise((resolve) => {
/** @type {import("http").Server} */
(this.server).listen(listenOptions, () => {
resolve();
});
})
);
}


async initialize() {
this.setupHooks();
this.setupApp();
this.setupHostHeaderCheck();
this.setupDevMiddleware();
// Should be after `webpack-dev-middleware`, otherwise other middlewares might rewrite response
this.setupBuiltInRoutes();
this.setupWatchFiles();
this.setupWatchStaticFiles();
this.setupMiddlewares();
this.createServer();
}
}

浏览器和服务端怎么做的通讯?具体在传递什么信息?

访问 webpack 提供的地址,打开控制台如下,可以看到浏览器和服务器之间有建立 websocket 通信的信息,但是我们开发的时候代码里并没有做这些额外的事情。我们就需要去看 webpack 是在哪里做的这些事。

通过探寻我们可以在发现 webpack 在开启本地服务前对 webpack 的配置入口做了修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 添加 client socket 代码
additionalEntries.push(`${require.resolve("../client/index.js")}?${webSocketURLStr}`);

// 添加 webpack dev-server 代码
if (this.options.hot === "only") {
additionalEntries.push(require.resolve("webpack/hot/only-dev-server"));
} else if (this.options.hot) {
additionalEntries.push(require.resolve("webpack/hot/dev-server"));
}

const webpack = compiler.webpack || require("webpack");

// 引入一些需要额外打包的内容
if (typeof webpack.EntryPlugin !== "undefined") {
for (const additionalEntry of additionalEntries) {
new webpack.EntryPlugin(compiler.context, additionalEntry, {
name: undefined,
}).apply(compiler);
}
}

这里 webpack 帮我们的入口又引入了两个额外的 module 来打包,其中引入的第一个 module 是与服务端建立 websocket 连接用的,第二个 module 就是客户端在接收到文件变化的通知后做处理用的。

所以浏览器之所以能感应到本地文件变化是本地服务主动推送的缘故。具体是服务端在监听到文件变化后会向客户端发送 hash 事件和 ok 的事件。hash 事件主要返回一个 hash 值,用来对每次的编译结果做标识,ok 事件是通知浏览器进行热重载用的。

hash 事件是啥具体下面热更新会说

1
2
3
4
sendStats(clients, stats, force) {
this.sendMessage(clients, "hash", stats.hash);
this.sendMessage(clients, "ok");
}

浏览器怎么做到的更新?

当检测到需要更新时就调用 reloadApp 刷新浏览器请求最新的资源,其中关键的代码是 window.location.reload()。这就是为什么浏览器会自动刷新的缘故。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function reloadApp() {
if (liveReload && allowToLiveReload) {
var rootWindow = self; // use parent window for reload (in case we're in an iframe with no valid src)

var intervalId = self.setInterval(function () {
if (rootWindow.location.protocol !== "about:") {
// reload immediately if protocol is valid
applyReload(rootWindow, intervalId);
} else {
rootWindow = rootWindow.parent;

if (rootWindow.parent === rootWindow) {
// if parent equals current window we've reached the root which would continue forever, so trigger a reload anyways
applyReload(rootWindow, intervalId);
}
}
});
}
}

function applyReload(rootWindow, intervalId) {
clearInterval(intervalId);
log.info("App updated. Reloading...");
rootWindow.location.reload();
}

四、热更新 HMR(热替换)

上面介绍的热重载虽然在一定程度上提高了开发效率,满足了我们的基本开发诉求,但是还有一个痛点就是不能在接收变化后保留之前的状态做局部更新,这样在测试一些功能的时候,比如输入框输入一些数据后才能做点击的测试,那我们每次更新完就必须重新输一遍再开始测试,这是非常低效的,热更新就很好的解决了这个问题。

1、使用

1.1、安装依赖

同上

1.2、项目配置

将 hot 的值修改为 true 或去掉该配置(默认值 true)。

1
2
3
devServer: {
hot: true // 为 false 时文件发生变化会刷新浏览器
}

1.3、运行

1
yarn dev

2、编译结果

同上

3、实现原理

3.1、执行过程

3.2、原理分析

这里主要关注两个问题是:

  • 和热重载相比,热更新编译的差异是什么?
  • 浏览器在监听到文件变化后做了什么事情?

和热重载相比,热更新编译的差异是什么?

实例化 HotModuleReplacementPlugin 插件,并监听对应的 hooks。

webpack4 需要手动引入

1
2
3
const plugin = new webpack.HotModuleReplacementPlugin();

plugin.apply(compiler);

首先会定义 module.hot 相关 api 的 dependency 以及 template

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class HotModuleReplacementPlugin {
apply(compiler) {
compiler.hooks.compilation.tap(
"HotModuleReplacementPlugin",
(compilation, { normalModuleFactory }) => {
//#region module.hot.* API
compilation.dependencyFactories.set(
ModuleHotAcceptDependency,
normalModuleFactory
);
compilation.dependencyTemplates.set(
ModuleHotAcceptDependency,
new ModuleHotAcceptDependency.Template()
);
compilation.dependencyFactories.set(
ModuleHotDeclineDependency,
normalModuleFactory
);
compilation.dependencyTemplates.set(
ModuleHotDeclineDependency,
new ModuleHotDeclineDependency.Template()
);
}
);
}
}

然后监听parser阶段,对module.hot等api进行解析,例如:

1
2
3
4
5
6
parser.hooks.call
.for("module.hot.accept")
.tap(
"HotModuleReplacementPlugin",
createAcceptHandler(parser, ModuleHotAcceptDependency)
);

因此module.hot等api经过parser后会变为相应的dependency。在code generate时,调用对应的template生成新的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 转换前
if (module.hot) {
module.hot.accept(['./moduleB.js'], () => {
console.log('======> accept B')
})
}

// 转换后
if (true) {
module.hot.accept([/*! ./moduleB.js */ "./src/moduleB.js"], __WEBPACK_OUTDATED_DEPENDENCIES__ => {
/* harmony import */ _moduleB__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./moduleB.js */ "./src/moduleB.js");
(() => {
console.log('======> accept B')
})(__WEBPACK_OUTDATED_DEPENDENCIES__);
})
}

浏览器在监听到文件变化后做了什么事情?

要知道浏览器在监听到文件变化后做了什么事情要先回到服务端监听到文件变化给客户端发送 hash 事件和 ok 的事件的时候,我们先看 hash 事件具体做了什么。

刚打开页面时

修改代码保存后

观察可以发现修改完代码后,浏览器新加载的文件后缀是上次的 hash,所以 Hash 值代表每一次编译的标识,上次输出的 Hash 值会作为本次编译新生成的文件标识。

知道了 hash 事件的概念后,我们再回到服务端向浏览器发送 hash 事件和 ok 事件,浏览器会调用 reloadApp 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// node_modules/webpack-dev-server/client/index.js
var onSocketMessage = {
hash: function hash(_hash) {
status.previousHash = status.currentHash;
status.currentHash = _hash;
},
ok: function ok() {
sendMessage("Ok");

if (options.overlay) {
hide();
}

reloadApp(options, status);
},
}
function reloadApp() {
if (hot && allowToHot) {
log.info("App hot update...");
hotEmitter.emit("webpackHotUpdate", status.currentHash);

if (typeof self !== "undefined" && self.window) {
// broadcast update to window
self.postMessage("webpackHotUpdate".concat(status.currentHash), "*");
}
} else if (liveReload && allowToLiveReload) {
window.location.reload();
}
}

接着触发 webpackHotUpdate 事件,经过一系列的检查后根据上次的 hash 值发送 fetch 请求获取需要更新的模块名。

img

再通过 JSONP 的方式请求这个新的模块,并执行相关模块的代码,这就起到了局部更新的作用。

五、总结

webpack compiler 对文件进行打包,打包好后把编译好的文件传输给 bundleServer, bundleServer 就以服务器的方式让浏览器访问。(1-2-A-B)

文件发生变化后,会再经过 webpack compiler 进行编译,编译后将代码发送给 HMR Server,HMR Server 知道哪些模块发生了改变,然后 HMR server 会以 websocket 的方式通知 HRM Runtime,HRM Runtime 通过 JSONP 的方式获取更新的内容,最后 HMR runtime 会根据返回的新模块代码做进一步处理,可能是刷新页面,也可能是对模块进行热更新。(1-2-3-4)

六、思考

1、构建工具热更新能力对比

gulp:支持热重载,热更新目前没发现支持特别好的包,需要自己实现。

工具 webpack vite
配置 简单 简单
实现原理 webpack4 文件监听用的 chokidar,webpack5 用的 fs.watchwebpack4 依赖 vue-hot-reload-api 实现模块热替换,webpack5 自己实现基于bundle进行构建 vite2 文件监听用的 chokidarvite2 依赖 vue-hot-reload-api 实现模块热替换基于bundleless进行构建
速度 冷启动时间长热更新能力受项目体积的影响 冷启动时间短热更新能力不受项目体积的影响
使用场景 对构建内容有严格要求且构建结果与平台架构关系紧密 对构建内容无严格要求

七、项目地址

https://github.com/Yin-Hongwei/webpack-hmr-demo

微信打赏